Skip to content

Accounts and mintable API tokens on Cloudflare D1 - #15

Open
codellyson wants to merge 24 commits into
mainfrom
feat/accounts-d1-auth
Open

Accounts and mintable API tokens on Cloudflare D1#15
codellyson wants to merge 24 commits into
mainfrom
feat/accounts-d1-auth

Conversation

@codellyson

Copy link
Copy Markdown
Owner

Turns JustAPI into an account-gated product. Users sign up, sign in, and mint personal access tokens that authenticate the MCP bridge and agents against their account — the bridge endpoints (/api/flows, /api/agent/*, /api/share/*) were previously wide open.

What's in it

  • Runtime — the app now runs on Cloudflare Workers via @opennextjs/cloudflare so the D1/R2 bindings are reachable. next dev keeps working (bindings injected via miniflare); pnpm preview/pnpm deploy build the real Worker.
  • Auth (better-auth + D1) — email/password + sessions + the apiKey plugin, persisted to D1 with the Drizzle adapter. getAuth(env) builds better-auth per-request from the request's D1 binding (the binding isn't available as a module singleton on Workers); app/api/auth/[...all] mounts the handler.
  • GatingrequireAuth guards the flows/agent/share routes, accepting either a session cookie (browser canvas) or Authorization: Bearer <token> (MCP/agent). middleware.ts optimistically redirects unauthenticated visitors to /login.
  • Frontend — dedicated /login, /signup, and /account routes (no inline gate cramping the canvas). The rail links to /account, where tokens are minted (plaintext shown once), listed, and revoked.
  • Share → R2 — the share store moved off @vercel/blob (which can't run on Workers) to an R2 bucket.
  • MCPmcp/server.mjs sends Authorization: Bearer $JUSTAPI_TOKEN and returns a clear "mint a token" message on 401.

Verification

  • Signup → session → mint verified against D1; the four gate cases pass (no-auth 401, cookie 200, bearer 200, bogus bearer 401).
  • R2 share create/read round-trip verified; unauthenticated read → 401.
  • /account mint-once UI, canvas load, and SSE-with-cookie verified in the browser.
  • tsc --noEmit clean; next build passes (all 13 routes + middleware).

Deploy notes

Deploy needs real Cloudflare resources: wrangler d1 create justapi (paste database_id into wrangler.jsonc, replacing the placeholder), wrangler r2 bucket create justapi-shares, wrangler secret put BETTER_AUTH_SECRET / BETTER_AUTH_URL, then pnpm db:migrate and pnpm deploy. Documented in the README's new "Deploy (Cloudflare)" section.

Follow-ups (out of scope here)

  • Canvas/flow data still lives in browser localStorage — per-user flow sync (and the deployed-Workers durability of the agent bridge) is the next persistence phase.
  • Public/anonymous share viewing: share GET is currently account-gated; a public render path is a later addition.

Turn JustAPI into an account-gated app. Users sign up, sign in, and mint
personal access tokens that authenticate the MCP bridge and agents against
their account — replacing the previously wide-open bridge endpoints.

- Runtime: run on Cloudflare Workers via @opennextjs/cloudflare so D1/R2
  bindings are available; next dev keeps working via miniflare.
- Auth: better-auth (email/password + sessions + apiKey plugin) persisted to
  D1 with the Drizzle adapter. getAuth(env) builds it per-request from the
  request's D1 binding; app/api/auth/[...all] mounts the handler.
- Gating: requireAuth guards the flows/agent/share routes, accepting either a
  session cookie or an Authorization: Bearer <token>. middleware.ts redirects
  unauthenticated visitors to /login.
- Frontend: dedicated /login, /signup, and /account routes; the rail links to
  /account, where tokens are minted (plaintext shown once), listed, and revoked.
- Share store moved off @vercel/blob to R2 (Vercel Blob can't run on Workers).
- MCP server sends Authorization: Bearer $JUSTAPI_TOKEN and reports a clear
  message on 401.

Canvas/flow data still lives client-side; per-user sync is a follow-up.
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
quick-rest Error Error Aug 2, 2026 2:50am

The canvas is now open to everyone: anonymous users work locally (localStorage),
and signing in unlocks the account-scoped features (agent bridge, sharing, token
minting, future remote sync).

- middleware.ts now guards only /account; / and the canvas are public.
- The agent-bridge SSE (useAgentSync) connects only when signed in, so anonymous
  sessions no longer hit the gated /api/agent/events in a 401 retry loop.
- The rail's account icon reflects state: "Sign in" (→ /login) when signed out,
  "Account" (→ /account) when signed in.

Bridge routes stay behind requireAuth; the change is purely which surfaces the
app forces you through.
AuthForm now holds its mode in state (seeded from initialMode) and flips between
sign-in and create-account in place — no navigation. /login and /signup both
still work as direct entry points; each just toggles to the other without a
route change.
/ is now a marketing landing page modeled on the justnotetaking site — nav,
hero with a live embedded canvas, a numbered feature trio, an editorial
statement, a closing CTA, and a footer, all on JustAPI's theme tokens (solid
accent, no gradients) with a light/dark toggle.

The canvas moves to /app (app/app/page.tsx). Redirects updated: the old
/canvas, /playground, /expand entry points and legacy /?s=ID share links now
land on /app (?s= carried across). Post-login also lands on /app, and the
account page's back-link points there.
The landing page's live-canvas iframe shares localStorage with the real app,
so left as-is a visitor could mutate their saved canvas by poking the preview.
Detect the framed context (isEmbedded) and, in that mode:

- persist reads the saved canvas but drops every write (no-op storage), so
  preview edits never touch real data;
- the agent-bridge SSE never connects;
- nodes can't be dragged, connected, selected, or deleted (pan/zoom stay).

Standalone /app is unchanged. Verified: standalone persists on a viewport
change; the embed leaves a localStorage sentinel untouched after the same change.
When embedded, render only the React Flow canvas — the rail, docked panes,
top control cluster, spec drawer, status bar, empty state, and tour are all
hidden. Standalone /app is unchanged.
The marketing embed now always renders a bundled demo (login → create → read,
with binding edges, captures, and asserts) instead of the visitor's saved
canvas — so first-time visitors see a real flow, not an empty board.

The embed's canvas storage ignores localStorage entirely (getItem → null) and
materializeFlow(DEMO_FLOW) builds the demo on mount. The demo spec declares no
environment, so materializing it touches only the (no-write) canvas store —
verified: nothing is written to localStorage. Standalone /app is unchanged.
The demo is materialized after React Flow's initial (empty-graph) fitView, and
the fitView prop doesn't re-fire on graph changes — so the tree sat un-framed
until the tidy button was clicked. Now, once the demo's nodes have mounted and
measured, run tidyGraph + fitView automatically (what the tidy button does), so
the flow fans out and centers itself on load.
Same root cause as the embed: React Flow's fitView prop only fires on the
initial mount, so when the active graph changes after that — an agent
materializes a flow, or you switch canvases — the view stayed put and the new
flow sat unframed until the tidy button was clicked.

On graph change, restore that graph's saved viewport, or fitView it if it has
none. No re-layout (unlike the embed's demo), so a hand-arranged board is never
reshuffled. Verified: pushing a flow over the agent bridge now reframes the
canvas onto it automatically.
The toggle only relabeled the run button ("Run flow" ↔ "Re-run") — the run
action was identical either way, and it touched nothing in the agent bridge.
Dead UI; drop it and keep the single Run flow button.
As each node starts executing (setPending), center the viewport on it so the
human watches the flow move through the tree. Keeps the current zoom — pans,
doesn't zoom — and only re-centers on a node it hasn't followed yet, clearing
when the run ends. Disabled in the embed (its preview never runs).
Clicking Run flow now pans the camera to the origin (wherever you were), locks
in a readable zoom, and walks node-by-node as each request executes.

Robustness fixes over the first attempt:
- subscribe to the run store instead of a React effect, so fast requests that
  finish between renders are still followed;
- pace the walk through a queue with a dwell, so a fast flow doesn't skip
  straight to the last node;
- only request nodes are followed (the origin stays pending for the whole run);
- capture a comfortable zoom (clamped) once per run, so the camera pans without
  drifting the zoom;
- start the walk at the origin.

Disabled in the embed (its preview never runs).
The flow summary ("N passed · M checks ✓") is stored in the run record's error
field regardless of outcome, and the status bar rendered anything there in red —
so a fully-passing run read as a failure. Color it green on success and keep red
only for actual failures (run.status === "error").
The marketing embed now has a large play button that runs the demo flow for
real against the app's own public sample API (same origin) — the camera follows
each request and the assert checks light up. It becomes a subtle "Replay" pill
once it has run.

- demo-flow.ts exports makeDemoFlow(base); the embed passes the current origin so
  it works in dev and when deployed.
- the environment store joins the canvas store as no-write in the embed, so a
  run's captured variables stay in memory and never touch real data (verified:
  neither the Demo canvas nor the Demo env is persisted).
- the follow-the-run camera now also runs in the embed.
The importer went from "method + path only" to producing runnable requests, and
gained URL/Swagger-UI-page import:

- parse-openapi.ts: resolve $ref, synthesize example JSON bodies from request
  schemas, pull path/query/header params, and map securitySchemes → request auth
  (bearer/basic/api-key). Now supports YAML as well as JSON, and Swagger 2.0 + 3.x.
- import dialog: paste a spec URL OR a Swagger UI / Redoc page — it fetches via
  the proxy, and for an HTML page discovers the spec URL it points at (or probes
  common paths like /openapi.json, /v3/api-docs). Optional Authorization field
  for protected specs.

Verified: unit test builds bodies from $ref'd schemas + bearer auth; importing
the petstore Swagger UI page (HTML) discovered its spec and produced 19 requests.
Turns a spec into runnable request nodes, not just method+path pairs.

- parse-openapi.ts: resolve $ref, synthesize example JSON bodies from schemas,
  pull path/query/header params, map securitySchemes → auth, carry tags, and
  accept YAML as well as JSON (v2 and v3).
- discover-spec.ts (new): resolve a URL to a spec — a raw spec, a spec inlined
  in Swagger UI's swagger-ui-init.js (swagger-ui-express), or an HTML docs page
  that advertises/references one (incl. common-path probing). Forwards an
  Authorization header to every hop for protected specs.
- proxy: return application/javascript + xml/yaml as text instead of a
  {}-serialized ArrayBuffer, so swagger-ui-init.js (which embeds the spec) reads.
- import dialog: paste-or-fetch-URL, an Authorization field for protected specs,
  and search + tag chips + selective import (capped) so large specs are usable
  a resource group at a time.

Verified against the Swagger Petstore (fetch → tag-filter "store" → import 4
nodes with a schema-generated POST body) and a real 864-path swagger-ui-express
spec (proxy text → extract swaggerDoc → parse).
Imported endpoints came in with absolute URLs from servers[0] (often a spec's
localhost dev server), which fought the {{base}}+environment workflow and landed
on top of existing nodes. Now:

- the parser emits {{base}}/path URLs and returns the declared servers;
- discovery resolves relative servers against the fetched URL;
- the dialog shows a base picker (defaults to the server matching the fetched
  host, else the last-declared — production usually follows local);
- importing creates/activates an environment whose base is the chosen server
  (reused if one already has that base), so {{base}} resolves;
- the batch is dropped below existing content, not over it.

Verified with the Petstore: base chip resolved to petstore3.swagger.io/api/v3,
rows show {{base}}/..., and importing the "store" tag created+activated the env
and placed the nodes clear of the board.
The paste box was a fixed 7-row textarea — too tall for the common "paste a URL
and fetch" case. Start it at 2 rows and grow with the content (capped at 10),
with a one-line placeholder.
A backdrop misclick closed the dialog and dropped a fetched spec + selection.
Remove the backdrop click-to-close; close only on the ✕ button or Escape.
…the current board

A spec import is a whole API, but the importer treated its endpoints like a
few pasted requests: it fanned them off the current origin, silently switched
the global active environment to the new API's base (re-basing every other
node on the board), and never moved the camera to the result.

- Add a new-canvas/this-canvas destination toggle, defaulting to a fresh
  canvas for specs and the current board for ad-hoc curl/fetch/HAR pastes.
- Create the {{base}} env and pin it to the destination origin instead of
  flipping the global active env, so importing one API never re-bases another.
- Seed the origin's default auth from the spec's security scheme.
- Frame the result: the graph-change effect fits a new canvas; a merge fits
  explicitly.
- Hide the footer add button until there are candidates, removing the second
  CTA that competed with Fetch during the URL step.
… pinned env

Two rough edges in the import flow:

- A spec with >40 endpoints opened with every row greyed out and Add disabled,
  forcing the user to discover 'pick a tag, then select all'. Now a big tagged
  spec opens on its first resource group fully selected; a big untagged spec
  opens with nothing selected (narrow by search); a small spec selects all.
  The rule is centralized in one effect so paste and fetch behave identically.
- The status bar always showed the global active environment, even on a canvas
  pinned to its own env — misleading now that imports pin per-canvas. It now
  shows the env the canvas actually runs under, marked '·pinned' when it comes
  from the origin rather than the global active env.
Social sign-in via better-auth. A provider is enabled only when both halves of
its credential are set, so the buttons stay hidden until configured (dev via
.dev.vars, prod via wrangler secrets) — the app runs fine email-only. No
migration: the existing account table already stores linked providers.

- getAuth() wires socialProviders from env; a server helper reports which are
  configured so the login/signup pages only render buttons for enabled ones.
- Account page gains a profile card (avatar, name, verified badge, member
  since), a Connected accounts section to link/unlink Google/GitHub (guarded so
  the last sign-in method can't be removed), and created/last-used on each token.
- README documents OAuth app setup and callback URLs.
Adds canvases / collections / requests / assertions / environments counts to
/account — the visibility needed before any usage-based subscription tier.

Read directly from the local canvas store on the client (keeps React Flow out
of this route's bundle, no SSR mismatch). Labeled device-local: these are not
yet authoritative per-account usage — that needs the deferred remote-persistence
phase, where the same counts move server-side to D1 and can be metered/enforced.
…vases)

Canvases and environments were localStorage-only, so account usage was
per-device and unenforceable. They now persist to D1 per account.

Schema (src/db/app-schema.ts — separate file so auth:generate can't clobber it):
canvas + environment tables, JSON blob + denormalized counts, migration 0001.

API (all requireAuth, userId-scoped): GET /api/canvases (workspace + usage +
limits + plan), PUT/DELETE /api/canvases/[id] (402 when a NEW canvas exceeds the
cap; updates never capped), POST /api/canvases/import (bulk, cap-bypassed —
grandfathers existing/offline work), PUT/DELETE /api/environments/[id].

Client (src/canvas/use-canvas-sync.ts, signed-in only, mirrors useAgentSync):
server is source of truth on load; a canvas the server lacks is uploaded
(never-synced local) or dropped (deleted elsewhere — tracked via a local
synced-ids set so deletes don't resurrect). Debounced push on change. Anonymous
users stay fully local.

Plan/limits: Free = 5 canvases (src/server/plan.ts; getUserPlan is the billing
seam). Create is gated client-side (createCanvasGuarded) with a toast, and the
account page shows usage as N/limit from the server.

Also: gate useAgentSync's connect-time flow rehydration off when canvas sync is
active — otherwise both restore the same boards and the agent path mints new
canvas ids, spawning duplicates the id-keyed sync can't dedupe.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant